<?php

// documentation format is NaturalDocs <http://naturaldocs.org>

/*
Class: Class Outline

	Revision $Id: occam.class.php 12 2008-05-29 20:00:18Z evan.kaufman $

	Copyright (C) 2008 Evan Kaufman (evan@markovchainsaw.com)

Topic: What Is Occam?

	Occam is a flexible caching system for PHP that can be 'dropped in' anywhere
	that data is retrieved.  It does this with a simple, minimal interface and in
	under 10kb of source code (and 6kb of it is comments).  Cached data is stored
	in flat files on the host filesystem, and can contain any kind of
	serializable data.

	As for the name, it comes from Occam's Razor, which roughly says that the
	simplest solution is the best.  Paraphrasing aside, this is a small and
	simple solution, and I like the name at any rate.

About: License

	Licensed under the MIT License

	<http://opensource.org/licenses/mit-license.html>
*/

class occam {
	// callable that will return data
	protected $callback;

	// structure that will be hashed and used as a cachefile name
	protected $key;

	// directory to store cachefiles, defaults to sys_get_temp_dir
	protected $directory;

	// how long a cachefile is considered valid (time to live), defaults to 5 min
	protected $ttl = 300;

	// indicates if in tidy mode, where we clean up all cache files in current directory but at a possible performance penalty
	protected $tidy_mode = FALSE;

	// indicate if a given directory has already been tidied, as we only tidy once per directory per instance
	protected $is_tidied = array();

	// indicates if last execute() call returned data from cache (true) or live data (false)
	// note initial value of false
	protected $cached = FALSE;

	// extension for cache files
	const CACHE_FILE_EXT = 'occam';

	/*
	Group: Public Methods
	Methods called externally on an object instance.
	*/

	/*
	Method: Constructor

		Creates a new instance, which can be reused any number of times for any
		number of different sources.

	Parameters:
		$directory - String, directory where cache files will be stored.  Defaults to the temp directory used by PHP.
		$ttl - Integer, time to live (in seconds) of a cached data source.  Defaults to 300 seconds (5 minutes).

	Returns:
		Object, an instance of the Occam class.
	*/
	public function __construct($directory = NULL, $ttl = NULL, $tidy = FALSE) {
		// set directory ot default to system temp directory
		if(!is_null($directory))
			$this->_directory($directory);
		else
			$this->directory = sys_get_temp_dir();

		// set time to live
		if(!is_null($ttl))
			$this->_ttl($ttl);

		// set tidy mode as boolean
		$this->tidy_mode = (bool) $tidy;
	}

	/*
	Method: get_directory()

		Gets the directory to which cache files will be saved and loaded.

	Returns:
		String, directory where cache files will be stored.
	*/
	public function get_directory() {
		return $this->directory;
	}

	/*
	Method: set_directory()

		Sets the cache directory for the current instance.

	Parameters:
		$directory - String, directory where cache files will be stored.

	Returns:
		Boolean, true on success.
	*/
	public function set_directory($directory) {
		$this->_directory($directory);
		return TRUE;
	}

	/*
	Method: get_ttl()

		Gets the time-to-live for the current instance.

	Returns:
		Integer, time to live (in seconds) of a cached data source.
	*/
	public function get_ttl() {
		return $this->ttl;
	}

	/*
	Method: set_ttl()

		Sets the time-to-live for the current instance.

	Parameters:
		$ttl - Integer, time to live (in seconds) of a cached data source.

	Returns:
		Boolean, true on success.
	*/
	public function set_ttl($ttl) {
		$this->_ttl($ttl);
		return TRUE;
	}

	/*
	Method: get_tidy()

		Gets the tidy mode for the current instance.

	Returns:
		Boolean, indicating whether on (TRUE) or off (FALSE).
	*/
	public function get_tidy() {
		return $this->tidy_mode;
	}

	/*
	Method: set_tidy()

		Sets the tidy mode for the current instance.

	Parameters:
		$tidy - Boolean, indicating whether on (TRUE) or off (FALSE).

	Returns:
		Boolean, true on success.
	*/
	public function set_tidy($tidy) {
		$this->tidy_mode = (bool) $tidy;
		return TRUE;
	}

	/*
	Method: is_cached()

		Indicates whether the most recent <execute> call returned cached data.  Note
		that if execute has never been called, this returns false by default.

	Returns:
		Boolean, true if last call returned cached data, false otherwise.
	*/
	public function is_cached() {
		return $this->cached;
	}

	/*
	Method: execute()

		Retrieves data, either by executing the given callback to produce live
		data, or by loading cached data from a previous call.

	Parameters:
		$key - Mixed, value unique to this data source.  Any non-scalar value will be serialized.
		$callback - Mixed, callback that, when executed with given arguments, will return data.
		$args - Array, optional arguments to be supplied to callback when it is executed.

	Returns:
		Mixed, data returned by the callback or retrieved from cache.
	*/
	public function execute($key, $callback, $args = array()) {
		// tidy up, if needed
		if($this->tidy_mode === TRUE)
			$this->tidy();

		// verify parameters
		$this->_callback($callback);
		$this->_key($key, $callback);

		// assume data is from cache, unless indicated otherwise
		$this->cached = TRUE;

		// attempt to retrieve data from cache
		$result = $this->retrieve();

		// if content returned, unserialize and return it
		if($result !== FALSE)
			return @unserialize($result);

		// otherwise indicate live data source and execute callback
		$this->cached = FALSE;
		$content = call_user_func_array($callback, $args);

		// store its (serialized) content in a cache file, if nonzero ttl given
		if($this->ttl > 0)
			$this->store( @serialize($content) );

		// and return content
		return $content;
	}

	/*
	Group: Protected Methods
	Methods called from within the class, ideal for overloading functionality.
	*/

	/*
	Method: store()

		Stores given data cached on the local filesystem.

	Parameters:
		$content - String, serialized content to cache.

	Returns:
		Boolean, TRUE on success or FALSE on failure.
	*/
	protected function store($content) {
		// clear before any filestat operations
		clearstatcache();

		// construct full pathname to cache file
		$cache_file = $this->directory . '/' . $this->key . '.' . self::CACHE_FILE_EXT;

		// pack ttl as file header
		$header = pack('N', $this->ttl);

		// write file content
		file_put_contents($cache_file, $header . $content);

		return TRUE;
	}
	
	/*
	Method: retrieve()

		Retrieves data cached on the local filesystem.

	Returns:
		Mixed, serialized content if available, FALSE otherwise.
	*/
	protected function retrieve() {
		// clear before any filestat operations
		clearstatcache();

		// construct full pathname to cache file
		$cache_file = $this->directory . '/' . $this->key . '.' . self::CACHE_FILE_EXT;

		// if ttl is not instant
		if($this->ttl > 0) {
			// and cached file for this key exists
			if(file_exists($cache_file)) {

				// open file
				$handle = @fopen($cache_file, 'r');
				if($handle === FALSE)
					return FALSE;

				// seek past embedded ttl (always honor the current instance ttl if available)
				$result = @fseek($handle, 4);
				if($result < 0 || feof($handle))
					return FALSE;

				// if file's age doesnt exceed time to live, get its content
				$content = FALSE;
				if(time() < ( filemtime($cache_file) + $this->ttl )) {
					$content = @fread($handle, filesize($cache_file));
				}

				// close file and return result
				@fclose($handle);
				return $content;
			}
		}

		// otherwise, no cached content available
		return FALSE;
	}
	
	/*
	Method: tidy()

		Tidies current directory by traversing cache files and removing any that are expired.

	Returns:
		Boolean, TRUE on success or FALSE on failure.
	*/
	protected function tidy() {
		// skip if already tidied this directory
		if(isset( $this->is_tidied[ $this->directory ] ))
			return;

		// iterate all cache files in current cache directory
		foreach (glob($this->directory . '/*.' . self::CACHE_FILE_EXT) as $cache_file) {
			// open file
			$handle = @fopen($cache_file, 'r');
			if($handle === FALSE)
				return FALSE;

			// read and unpack stored ttl, and close file
			$ttl = array_shift( @unpack('N', @fread($handle, 4)) ) * 1;
			@fclose($handle);

			// if file's age meets or exceeds ttl, remove it
			if(time() >= ( filemtime($cache_file) + $ttl )) {
				@unlink($cache_file);
			}
		}

		// indicate that we've tidied this directory
		$this->is_tidied[ $this->directory ] = TRUE;
	}

	/* hidden methods beyond this point, nothing that need be documented */

	// ensure callable
	protected function _callback($callback) {
		// if null callback provided, raise error
		if(is_null($callback))
			throw new Exception('Missing argument \'callback\' for occam::retrieve()');

		// if not callable, raise error
		if(!is_callable($callback))
			throw new Exception('Provided callback is not callable');

		// otherwise return verified callback
		$this->callback = $callback;
	}

	// ensure exists and writeable
	protected function _directory($directory) {
		// clear before any filestat operations
		clearstatcache();

		// if not a string, use default of system temp dir
		if(!is_string($directory))
			$directory = sys_get_temp_dir();

		// resolve any symlinks or path shortcuts
		$real_directory = realpath($directory);
		if($real_directory)
			$directory = $real_directory;

		// if not valid, raise error
		if(!is_dir($directory))
			throw new Exception('Provided directory "' . $directory . '" is not a valid path');

		// if not writable directory, raise warning
		if(!is_writable($directory))
			throw new Exception('Provided directory "' . $directory . '" is not writeable');

		$this->directory = $directory;
	}

	// ensure serializeable and hashable
	protected function _key($key, $callback) {
		// if null key provided, raise error
		if(is_null($key))
			throw new Exception('Missing argument \'key\' for occam::retrieve()');

		// dont even try to serialize a resource
		if(is_resource($key))
			throw new Exception('Provided key is a resource and cannot be serialized');

		// serialize key if not a scalar value
		if(!is_scalar($key))
			$key = @serialize($key);

		// hash and return key
		$this->key = md5($key);
	}

	// ensure an integer
	protected function _ttl($ttl) {
		// if ttl is not a number, raise error
		if(!is_numeric($ttl))
			throw new Exception('Provided ttl is not numeric');

		// if ttl is negative, raise a warning
		if($ttl < 0)
			throw new Exception('Provided ttl is a negative number');

		// return ttl cast as an integer
		$this->ttl = (int) $ttl;
	}

}
